Skip to content
View Article Network

DateTime Time Zone Issues and Solutions in Entity Framework

Although many projects run exclusively in Taiwan and do not need to consider time zone issues, the popularity of cloud environments—where many servers are set to Coordinated Universal Time (UTC +0)—means we must start paying attention to this.

I have always known that the UTC format of DateTime can be a trap, so I generally try to use DateTimeOffset when dealing with time zones. Since I encountered a related scenario recently, I did some research and decided to document it.

A colleague reported that their project had agreed to use UTC time with the frontend, but when passing DateTime data retrieved from the database to the frontend, they found the time was off by 8 hours. To solve this, they used the ToString() method to format the time as yyyy-MM-ddTHH:mm:ssZ.

I asked them why they were appending Z to the end of the time string. They replied that it was the only way to prevent the time from being off by 8 hours. I looked it up, and according to the ISO 8601 entry on Wikipedia, Z denotes the UTC +0 time zone.

I initially wanted to help them optimize this by changing how the DateTime type is handled in JsonSerializerOptions.Converters. However, I realized that many projects use DateTime for UTC +0, such as the well-known framework ABP.IO. ASP.NET Core should be handling this correctly. I checked online and confirmed that if a DateTime is in UTC format, it should indeed end with Z. I ran the following test:

csharp
DateTime localTime = new DateTime(2024, 8, 14, 8, 0, 0, DateTimeKind.Local);
DateTime utcTime = new DateTime(2024, 8, 14, 8, 0, 0, DateTimeKind.Utc);
DateTime unspecifiedTime = new DateTime(2024, 8, 14, 8, 0, 0, DateTimeKind.Unspecified);

Console.WriteLine("Local:" + localTime.ToString("O"));
Console.WriteLine("UTC:" + utcTime.ToString("O"));
Console.WriteLine("Unspecified:" + unspecifiedTime.ToString("O"));

The results are as follows:

text
Local:2024-08-14T08:00:00.0000000+08:00
UTC:2024-08-14T08:00:00.0000000Z
Unspecified:2024-08-14T08:00:00.0000000

Comparing this to my colleague's statement, the mystery is solved:

But when passing DateTime data retrieved from the database to the frontend, the time was off by 8 hours.

TIP

The complete executable sample for this article: CloudyWing/EfCoreBehaviorSample.

The Time Zone Format Issue with DateTime

The DateTime type has a Kind property used to indicate the source of the time, with the following enum values:

ValueProperty NameDescription
0UnspecifiedNot specified
1UtcCoordinated Universal Time (UTC)
2LocalLocal time

If the Kind format is unclear, using ToLocalTime() or ToUniversalTime() to switch times will result in unexpected values.

Here is the test code:

csharp
DateTime utcNow = DateTime.UtcNow;
DateTime now = DateTime.Now;

Print("Original time:");
PrintNow("Local", now);
PrintNow("Utc", utcNow);
Console.WriteLine();

Print("Switch Kind to Local");
PrintTime(DateTime.SpecifyKind(now, DateTimeKind.Local));
Console.WriteLine();

Print("Switch Kind to Utc:");
PrintTime(DateTime.SpecifyKind(now, DateTimeKind.Utc));
Console.WriteLine();

Print("Switch Kind to Unspecified:");
PrintTime(DateTime.SpecifyKind(now, DateTimeKind.Unspecified));

void Print(string str) {
    Console.WriteLine(str);
}

void PrintNow(string title, DateTime dateTime) {
    Print($"{title}:{dateTime:O}, Kind:{dateTime.Kind}");
}

void PrintTime(DateTime dateTime) {
    Print($"Original:{dateTime:O}, Kind:{dateTime.Kind}");

    DateTime local = dateTime.ToLocalTime();
    Print($"Local:{local:O}, Kind:{local.Kind}");

    DateTime utc = dateTime.ToUniversalTime();
    Print($"Utc:{utc:O}, Kind:{utc.Kind}");
}

The results are as follows:

text
Original time:
Local:2024-08-15T10:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T02:35:48.8421977Z, Kind:Utc

Switch Kind to Local
Original:2024-08-15T10:35:48.8422172+08:00, Kind:Local
Local:2024-08-15T10:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T02:35:48.8422172Z, Kind:Utc

Switch Kind to Utc:
Original:2024-08-15T10:35:48.8422172Z, Kind:Utc
Local:2024-08-15T18:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T10:35:48.8422172Z, Kind:Utc

Switch Kind to Unspecified:
Original:2024-08-15T10:35:48.8422172, Kind:Unspecified
Local:2024-08-15T18:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T02:35:48.8422172Z, Kind:Utc

As you can see from the results:

  • When Kind is Local, calling ToLocalTime() does not change the time.
  • When Kind is Utc, calling ToUniversalTime() does not change the time.
  • When Kind is Unspecified, because the time type is uncertain, calling ToLocalTime() causes the system to assume the time was originally UTC and converts it to local time, thus adding a time zone offset. Conversely, calling ToUniversalTime() causes the system to assume the time was originally local and subtracts the time zone offset.

Because of this, ABP.IO defines an IClock interface when using DateTime to correct the Kind and avoid unexpected issues. Below is an excerpt of their Clock code, which compares the configured Kind with the Kind of the time to be normalized to determine the conversion result. For more specific details, refer to the official documentation: "Timing".

csharp
public virtual DateTime Normalize(DateTime dateTime) {
    if (Kind == DateTimeKind.Unspecified || Kind == dateTime.Kind) {
        return dateTime;
    }

    if (Kind == DateTimeKind.Local && dateTime.Kind == DateTimeKind.Utc) {
        return dateTime.ToLocalTime();
    }

    if (Kind == DateTimeKind.Utc && dateTime.Kind == DateTimeKind.Local) {
        return dateTime.ToUniversalTime();
    }

    return DateTime.SpecifyKind(dateTime, Kind);
}

Time Zone Issues with DateTime in Entity Framework

If your database columns use types like datetime or datetime2 that do not include time zone information, the time stored in the database will not contain time zone data. However, when Entity Framework retrieves the data and maps it to the DateTime type, it cannot determine the Kind, so the Kind becomes Unspecified. Consequently, the time value returned to the frontend will not include a Z at the end.

In this case, the correct approach is not to append Z when returning the value, but to set the Kind of the DateTime type to Utc when retrieving the data from the database. Although DateTime does not consider Kind when comparing values, calling ToLocalTime() or ToUniversalTime() when DateTime.Kind is ambiguous can lead to unexpected results.

Solution

If you are using Code First, you know this is where ValueConverter comes in. When defining the Entity structure in OnModelCreating() using Fluent API, you can use HasConversion() to handle conversions during data write and read operations. Common use cases include Enum, Enum Object, and time zone handling. For more details, refer to Microsoft's documentation on "Value Conversions". This article focuses on this specific issue.

You can use HasConversion() to perform the following:

  • When writing data, if the DateTime Kind is not Utc, call ToUniversalTime() to convert it.
  • When retrieving data, set the DateTime Kind to Utc.

The specific code is as follows:

csharp
modelBuilder.Entity<Test>(entity => {
    entity.Property(x => x.TestDateTime)
        .HasConversion(
            v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
            v => DateTime.SpecifyKind(v, DateTimeKind.Utc)
        );
});

You can also define a UtcDateTimeValueConverter class for reuse:

csharp
public class UtcDateTimeValueConverter : ValueConverter<DateTime, DateTime> {
    public UtcDateTimeValueConverter()
        : base(v => ToDb(v), v => FromDb(v)) {
    }

    private static DateTime ToDb(DateTime dateTime) {
        return dateTime.Kind == DateTimeKind.Utc ? dateTime : dateTime.ToUniversalTime();
    }

    private static DateTime FromDb(DateTime dateTime) {
        return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
    }
}

Using UtcDateTimeValueConverter for conversion:

csharp
modelBuilder.Entity<Test>(entity => {
    entity.Property(x => x.TestDateTime)
        .HasConversion<UtcDateTimeValueConverter>();
});

If you don't want to configure each property individually, you can handle them uniformly like this:

csharp
foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes()) {
    foreach (IMutableProperty property in entityType.GetProperties()) {
        if (property.ClrType == typeof(DateTime) || property.ClrType == typeof(DateTime?)) {
            property.SetValueConverter(typeof(UtcDateTimeValueConverter));
        }
    }
}

With Code First, you can define your DbContext however you like, and the above approach works. However, if you are using reverse engineering to generate Entities and DbContext, the DbContext usually contains the following code:

csharp
public partial class MyDbContext : DbContext {
    // Omitted...

    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        // Omitted Entity definitions

        OnModelCreatingPartial(modelBuilder);
    }

    partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

In this case, you can write a partial class to add custom configurations. Note that the namespace must match the namespace of the MyDbContext generated by reverse engineering:

csharp
public partial class MyDbContext {
    partial void OnModelCreatingPartial(ModelBuilder modelBuilder) {
        foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes()) {
            foreach (IMutableProperty property in entityType.GetProperties()) {
                if (property.ClrType == typeof(DateTime) || property.ClrType == typeof(DateTime?)) {
                    property.SetValueConverter(typeof(UtcDateTimeValueConverter));
                }
            }
        }
    }
}

Of course, I have no objection to writing a separate DbContext that inherits from the original one and using that custom DbContext in your application.

In .NET 6, there is an even simpler configuration method: ConfigureConventions(). For details, refer to the Microsoft documentation:

csharp
public partial class MyDbContext {
    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) {
        ArgumentNullException.ThrowIfNull(configurationBuilder);

        configurationBuilder.Properties<DateTime>().HaveConversion<UtcDateTimeValueConverter>();
    }

Since ConfigureConventions() executes before OnModelCreating(), it can be used to define default values and conventions. If you need to override specific settings, it is appropriate to define them in OnModelCreatingPartial().

Change Log

  • 2026-05-29 Added link to the corresponding GitHub sample project.
  • 2024-08-15 Initial document creation.